Hexagonal Architecture, also known as Ports and Adapters, is a design pattern that promotes decoupling between the core application logic and external components such as databases, APIs, and user interfaces.
The key principles include:
In Layered Architecture, dependencies typically flow from top to bottom (e.g., UI -> Service -> Repository), whereas in Hexagonal Architecture, dependencies are inverted using ports and adapters, making business logic independent of external systems.
A Port is an interface that defines how the core application interacts with the outside world, such as database operations or external APIs.
public interface UserRepository { User findById(Long id); void save(User user); }
An Adapter is an implementation of a Port that connects the application to an external system, such as a database or API.
import org.springframework.stereotype.Repository; @Repository public class JpaUserRepository implements UserRepository { private final JpaRepository repository; public JpaUserRepository(JpaRepository repository) { this.repository = repository; } @Override public User findById(Long id) { return repository.findById(id).orElse(null); } @Override public void save(User user) { repository.save(user); } }
Dependency Injection allows the application to dynamically inject different adapters at runtime, promoting flexibility and testability.
The Application Service orchestrates business logic by calling domain services and repositories.
@Service public class UserService { private final UserRepository userRepository; public UserService(UserRepository userRepository) { this.userRepository = userRepository; } public User getUser(Long id) { return userRepository.findById(id); } }
By mocking adapters and testing only the core business logic through unit tests.
Interfaces help in defining contracts that allow business logic to be decoupled from specific implementations, improving maintainability and flexibility.
public class User { private Long id; private String name; public User(Long id, String name) { this.id = id; this.name = name; } public Long getId() { return id; } public String getName() { return name; } }
By decoupling business logic from external dependencies, making it easier to test core logic using mock implementations.
Inbound adapters handle input from external sources (e.g., REST controllers), while outbound adapters interact with external systems (e.g., databases, APIs).
@RestController @RequestMapping("/users") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping("/{id}") public ResponseEntitygetUser(@PathVariable Long id) { return ResponseEntity.ok(userService.getUser(id)); } }
Ports define clear contracts for interactions, ensuring that business logic is isolated from external dependencies.
DTOs are used to transfer data between layers without exposing domain entities directly.
public class UserDTO { private String name; public UserDTO(String name) { this.name = name; } public String getName() { return name; } }
By implementing different outbound adapters for various data sources while keeping business logic unchanged.
@Repository public class MongoUserRepository implements UserRepository { private final MongoTemplate mongoTemplate; public MongoUserRepository(MongoTemplate mongoTemplate) { this.mongoTemplate = mongoTemplate; } @Override public User findById(Long id) { return mongoTemplate.findById(id, User.class); } }
Events help decouple services by allowing asynchronous communication through event-driven mechanisms like Kafka or RabbitMQ.
It is widely used in microservices, financial applications, and systems requiring flexibility and adaptability to change.
@Service public class UserDomainService { public boolean validateUser(User user) { return user.getName() != null && !user.getName().isEmpty(); } }
It ensures that high-level modules do not depend on low-level modules, promoting abstraction and flexibility.
Interfaces define contracts for communication between ports and adapters, ensuring loose coupling.
public interface UserService { User getUser(Long id); void saveUser(User user); }
Common mistakes include tightly coupling adapters to business logic and misusing dependency injection.
By using custom exception handling strategies in the application layer and logging errors at the adapter level.
@ResponseStatus(HttpStatus.NOT_FOUND) public class UserNotFoundException extends RuntimeException { public UserNotFoundException(String message) { super(message); } }
By adding logging at each adapter boundary and ensuring logging does not pollute business logic.
Spring Boot simplifies dependency management, provides built-in dependency injection, and facilitates adapter creation.
Common structures include separating domain, application, and adapter layers into different packages.
By isolating concerns, making it easier to change implementations without modifying business logic.
public interface UserRepository { User findById(Long id); void save(User user); }
public interface OrderService { Order createOrder(OrderRequest request); Order getOrderById(Long id); }
public interface PaymentProcessor { boolean processPayment(PaymentDetails details); }
Spring Boot provides dependency injection, simplifies configuration, and supports separation of concerns.
@RestController @RequestMapping("/orders") public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService = orderService; } @PostMapping public ResponseEntitycreateOrder(@RequestBody OrderRequest request) { return ResponseEntity.ok(orderService.createOrder(request)); } }
@Component public class StripePaymentProcessor implements PaymentProcessor { @Override public boolean processPayment(PaymentDetails details) { // Call external Stripe API return true; } }
Unit testing for domain logic, integration testing for adapters, and contract testing for APIs.
It allows decoupled communication between components through events and message brokers.
@Configuration public class AppConfig { @Bean public OrderService orderService(OrderRepository repository) { return new OrderServiceImpl(repository); } }
public class CreateOrderUseCase { private final OrderRepository orderRepository; public CreateOrderUseCase(OrderRepository orderRepository) { this.orderRepository = orderRepository; } public Order execute(OrderRequest request) { Order order = new Order(request); orderRepository.save(order); return order; } }
DTOs help separate internal domain models from external API contracts, improving flexibility.
A domain service encapsulates business logic that doesn’t naturally fit within an entity or value object.
public class DiscountService { public double applyDiscount(double price, double discountRate) { return price - (price * discountRate); } }
By decoupling business logic from infrastructure, unit tests can focus on core logic without dependencies.
@Test public void testCreateOrder() { OrderRepository mockRepository = mock(OrderRepository.class); OrderService orderService = new OrderServiceImpl(mockRepository); OrderRequest request = new OrderRequest("Item1", 2); Order order = orderService.createOrder(request); assertNotNull(order); }
Over-engineering, excessive abstraction, and lack of clear separation between ports and adapters.
By treating events as domain objects and using an event store to persist them.
Application services coordinate use cases by interacting with domain objects and ports.
public class OrderApplicationService { private final OrderService orderService; public OrderApplicationService(OrderService orderService) { this.orderService = orderService; } public Order createOrder(OrderRequest request) { return orderService.createOrder(request); } }
By using an adapter to publish and consume messages via an external message broker like RabbitMQ.
@Component public class OrderEventPublisher { private final ApplicationEventPublisher eventPublisher; public OrderEventPublisher(ApplicationEventPublisher eventPublisher) { this.eventPublisher = eventPublisher; } public void publishOrderCreatedEvent(Order order) { eventPublisher.publishEvent(new OrderCreatedEvent(order)); } }
A repository abstracts the persistence logic, allowing domain services to interact with it without knowing implementation details.
public interface OrderRepository { void save(Order order); Order findById(String orderId); }
By enforcing clear separation between concerns, it makes it easier to modify, extend, and replace individual components.
It allows dependencies such as repositories and services to be injected at runtime, reducing coupling.
Inbound ports define application entry points (e.g., REST controllers), while outbound ports define external dependencies (e.g., database, messaging).
@Repository public class JpaOrderRepository implements OrderRepository { private final JpaRepository repository; public JpaOrderRepository(JpaRepository repository) { this.repository = repository; } @Override public void save(Order order) { repository.save(order); } @Override public Order findById(String orderId) { return repository.findById(orderId).orElse(null); } }
DTOs (Data Transfer Objects) transfer data between layers without exposing domain models directly.
public class OrderDTO { private String orderId; private String item; private int quantity; // Getters and Setters }
public class OrderMapper { public static OrderDTO toDTO(Order order) { return new OrderDTO(order.getId(), order.getItem(), order.getQuantity()); } public static Order toDomain(OrderDTO dto) { return new Order(dto.getOrderId(), dto.getItem(), dto.getQuantity()); } }
Interfaces allow implementations to be swapped without modifying dependent code, promoting flexibility.